250608 源生?启动!
前言
预览
使用如下代码:
Flow.Create()
.Then(() => GetComponent<SpriteRenderer>().ToCol(Color.black).Easing(Transition.InOutElastic).During(5).Play())
.Then(() => transform.FromPos(Vector3.left * 5 + Vector3.up * 2).ToPos(Vector3.right * 5 + Vector3.up * 2)
.ToRot(Vector3.forward * -360)
.During(2).Play())
.Delay(() => transform.AddPos(Vector3.down * 4).During(2).Easing(Transition.OutBounce))
.Then(() => transform.AddRot(Vector3.forward * 1440).During(3).Easing(Transition.InQuad).Play())
.Then(() => transform.AddScale(Vector3.one * 2).During(3).Play())
.Run();
实现这样的效果:

起因
由于devkit中的Flow很好用,我尝试不那么严谨地复现它。注意到Flow依赖于Tween,这不禁让我想起DOTween。虽然我没有用过DOTween,但是见过它看起来很便捷的拓展方法。于是重心从复现Flow转移到了土制魔改版DOTween。
在实现过程中,我发现其中有不少重复的工作。
以SpriteRenderer为例,我们需要:
- 一个构造函数与Init方法初始化这个动画
- 一个Process方法更新动画
- ……
如果要对它的color变量进行操作,我们需要:
- FromCol,ToCol方法表明动画的起止状态
- 扩展方法的对接
- ……
试想我们要扩展的组件不止SpriteRenderer,还有Transform;要控制的变量不止color,还有position、rotation……而上面提到的工作都是机械式的操作,并且确实可以由机械来操作,我们自然而然地会把目光投向代码生成。
代码生成有许多种方法,devkit的AudioMap里直接当做写文本文件然后改后缀叫代码生成,T4 Template也叫代码生成,源代码生成器也叫代码生成。只是我比较偏好最新最热的源代码生成器,所以选择了它。
好吧我承认假如“源生”指的是T4 Template我应该会首先考虑这种老东西。。。
简介
参照某乎上的一篇文章:
源代码生成器是在 C# 9 中引入的一个特性,它允许在编译过程中动态生成代码。它们直接与 C# 编译器 (Roslyn) 集成,并在编译时操作,分析源代码并基于分析结果生成附加代码。 源代码生成器提供了一种流线型(我总感觉这儿应该是“管道式”)的、自动化的代码生成方法,消除了对外部工具或单独预编译步骤的需求。
通过无缝集成到编译过程中,源代码生成器增强了生产力,减少了错误,并允许更高效的开发工作流。
虽然本人是参照这篇文档在Unity6里面操作的,但是根据另一篇文档,早在2021.3中,Unity就支持源生成器了。不过不同Unity版本的支持程度不同,例如前者说“您的源代码生成器必须使用Microsoft.CodeAnalysis.Csharp 4.3才能与 Unity 协同工作”,而后者是Microsoft.CodeAnalysis.Csharp 3.8,需要根据版本仔细鉴别。
正文
目标
以SpriteRenderer为例,为了改变SpriteRenderer的color,我们需要编写如下两个脚本:
// SpriteRendererTransition.cs
using UnityEngine;
public class SpriteRendererTransition : Transition
{
private readonly SpriteRenderer target;
private Color col, fromCol, toCol;
public SpriteRendererTransition FromCol(Color col)
{
this.col = fromCol = col;
return this;
}
public SpriteRendererTransition ToCol(Color col)
{
toCol = col;
return this;
}
public SpriteRendererTransition(SpriteRenderer target) : base()
{
this.target = target;
this.col = fromCol = toCol = target.color;
}
protected override void Init()
{
base.Init();
target.color = col;
}
protected override void Process(float delta)
{
base.Process(delta);
if (!target)
{
Drop();
return;
}
target.color += delta * (toCol - fromCol);
}
}
// SpriteRendererTransitionExtensions.cs
using UnityEngine;
public static class SpriteRendererTransitionExtensions
{
public static SpriteRendererTransition FromCol(this SpriteRenderer target, Color col) => new SpriteRendererTransition(target).FromCol(col);
public static SpriteRendererTransition ToCol(this SpriteRenderer target, Color col) => new SpriteRendererTransition(target).FromCol(target.color).ToCol(col);
public static SpriteRendererTransition AddCol(this SpriteRenderer target, Color col) => new SpriteRendererTransition(target).FromCol(target.color).ToCol(target.color + col);
}
南无三,多么冗长的代码!为了改变SpriteRenderer的color,我们真正需要关注的,事实上只有Init与Process方法的最后一句而已!
我希望可以只写这么几行,就能完成对一个组件(里面我们关注的属性)的拓展:
// SpriteRendererTransition.cs
using UnityEngine;
namespace SubstanceP
{
[TransitionTarget(typeof(SpriteRenderer))]
public partial class SpriteRendererTransition : Transition
{
// 虽然这个字符串找属性的设计有点问题,但是把它当成编译期反射就好接受多了(?)
// 我也是在这个时候才明白为什么它在某些场景中是反射的上位替代
[TransitionField("color")] private Color col;
private partial void InitCol(SpriteRenderer target) => target.color = col;
private partial void ProcessCol(SpriteRenderer target, Color delta) => target.color += delta;
}
}
相应的,以下脚本应该自动生成:
// SpriteRendererTransition.g.cs
using UnityEngine;
namespace SubstanceP
{
public partial class SpriteRendererTransition : Transition
{
private readonly SpriteRenderer target;
private Color fromCol, toCol;
private partial void InitCol(SpriteRenderer target);
private partial void ProcessCol(SpriteRenderer target, Color delta);
public SpriteRendererTransition FromCol(Color col)
{
this.col = fromCol = col;
return this;
}
public SpriteRendererTransition ToCol(Color col)
{
toCol = col;
return this;
}
public SpriteRendererTransition(SpriteRenderer target) : base()
{
this.target = target;
this.col = fromCol = toCol = target.color;
}
protected override void Init()
{
base.Init();
InitCol(target);
}
protected override void Process(float delta)
{
base.Process(delta);
if (!target)
{
Drop();
return;
}
ProcessCol(target, delta * (toCol - fromCol));
}
}
}
// SpriteRendererExtensionsForTransition.g.cs
using UnityEngine;
namespace SubstanceP
{
public static class SpriteRendererExtensionsForTransition
{
public static SpriteRendererTransition FromCol(this SpriteRenderer target, Color col) => new SpriteRendererTransition(target).FromCol(col);
public static SpriteRendererTransition ToCol(this SpriteRenderer target, Color col) => new SpriteRendererTransition(target).FromCol(target.color).ToCol(col);
public static SpriteRendererTransition AddCol(this SpriteRenderer target, Color col) => new SpriteRendererTransition(target).FromCol(target.color).ToCol(target.color + col);
}
}
实现
环境
创建 .NET Standard 2.0 类库项目之后,导入Microsoft.CodeAnalysis.CSharp,然后以文本方式打开.csproj文件,将这个包的版本降到你的Unity支持的版本。可能还有些别的问题,总之一通操作下来,这个.csproj文件应该长这样:
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.0</TargetFramework>
<EnforceExtendedAnalyzerRules>true</EnforceExtendedAnalyzerRules>
<LangVersion>9.0</LangVersion>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.3.0" />
</ItemGroup>
</Project>
接下来才是紧张刺激的编码环节。
由于源生成器依赖于Roslyn语法分析,而相关文档和分享有些零散,有实质内容的通常深入而不浅出,主角还往往是Unity6都不支持的IIncrementalGenerator……总之查资料性质的操作基本都交给AI了。
源生
ISourceGenerator接口要求实现:
- void Initialize(GeneratorInitializationContext ctx)
- void Execute(GeneratorExecutionContext ctx)
前者用于筛选出我们感兴趣的语法,后者根据筛选出来的语法,在方法体中调用GeneratorExecutionContext.AddSource(string, SourceText)添加脚本,达成代码生成的目的。
Initialize
创建一个继承自ISyntaxReceiver的类,用于接收我们感兴趣的语法:
- 拥有TransitionTarget标签且继承自Transition的部分类
- 拥有TransitionField标签的字段
对于一些比较繁琐的操作,考虑到代码可读性以及功能的可复用性,我们把它封装进拓展方法:
// 这个类里面有一大堆Roslyn相关的东西,十分复杂,我也讲不明白
internal static class SyntaxExtensions
{
internal static bool DirectlyInheritsFrom(this ClassDeclarationSyntax node, string name)
=> node.BaseList != null && node.BaseList.Types.Any(t => t.ToString() == name);
internal static bool IsPartialClass(this ClassDeclarationSyntax node)
=> node.Modifiers.Any(m => m.IsKind(SyntaxKind.PartialKeyword));
internal static bool HasAttribute(this MemberDeclarationSyntax node, string name)
=> node.AttributeLists.Any(al => al.Attributes.Any(a => a.Name.ToString() == name));
}
这样一来,主要逻辑就清晰了:
internal class TransitionSyntaxReceiver : ISyntaxReceiver
{
internal List<ClassDeclarationSyntax> Transitions { get; } = new();
internal List<FieldDeclarationSyntax> Fields { get; } = new();
public void OnVisitSyntaxNode(SyntaxNode node)
{
if (node is ClassDeclarationSyntax classNode &&
classNode.HasAttribute("TransitionTarget") &&
classNode.IsPartialClass() &&
classNode.DirectlyInheritsFrom("Transition"))
Transitions.Add(classNode);
if (node is FieldDeclarationSyntax fieldNode &&
fieldNode.HasAttribute("TransitionField"))
Fields.Add(fieldNode);
}
}
在Initialize方法中注册这个语法接收器:
public void Initialize(GeneratorInitializationContext ctx) =>
ctx.RegisterForSyntaxNotifications(() => new TransitionSyntaxReceiver());
Execute
由于我们只处理自己感兴趣的语法,所以如果我们拿到的不是上一步写的语法接收器就直接返回:
if (ctx.SyntaxReceiver is not TransitionSyntaxReceiver receiver) return;
由于我们要生成两个脚本,一个部分类,一个扩展方法,而前者包含ctor、Init、Process三个需要动态调整的方法,所以我们需要准备五个StringBuilder:
StringBuilder partialBuilder = new(), extensionBuilder = new(), ctorBuilder = new(), initBuilder = new(), processBuilder = new();
我们需要对每一个符合条件的类分别处理,以故需要遍历TransitionSyntaxReceiver.Transitions中每一个ClassDeclarationSyntax,每轮遍历通过一个脚本产生两个脚本
foreach (var cls in receiver.Transitions) // ...
对于一个人工写的脚本,它的开头包含以下信息:
- 引用了哪些命名空间(using ...)
- 是否属于命名空间,属于哪个命名空间(namespace ...)
- 想要拓展什么组件([TransitionTarget(typeof(...))]
- 这个部分类的名字
internal static string GetNamespace(this SyntaxNode node)
{
var current = node;
while (current is not NamespaceDeclarationSyntax)
{
current = current.Parent;
if (current == null) return string.Empty;
}
return (current as NamespaceDeclarationSyntax).Name.ToString();
}
internal static AttributeSyntax GetAttribute(this MemberDeclarationSyntax node, string name)
{
foreach (var al in node.AttributeLists)
foreach (var a in al.Attributes)
if (a.Name.ToString() == name) return a;
return null;
}
internal static T GetUnique<T>(this AttributeSyntax node) where T : ExpressionSyntax
=> node.ArgumentList.Arguments.Select(a => a.Expression).OfType<T>().First();
internal static IEnumerable<string> GetUsingsInFile(this ClassDeclarationSyntax node)
=> node.SyntaxTree.GetRoot().DescendantNodes().OfType<UsingDirectiveSyntax>().Select(u => u.Name.ToString());
// 清空StringBuilder处理完上一个脚本的残余
partialBuilder.Clear();
extensionBuilder.Clear();
ctorBuilder.Clear();
initBuilder.Clear();
processBuilder.Clear();
// 拿到这个脚本的上述信息
string className = cls.Identifier.Text, namesp = cls.GetNamespace(); // SpriteRendererTransition, SubstanceP
var targetType = cls.GetAttribute("TransitionTarget").GetUnique<TypeOfExpressionSyntax>().Type; // SpriteRenderer
var starting = namesp == string.Empty ? string.Empty : $"namespace {namesp}\n{{";
var us = cls.GetUsingsInFile(); // 包含UnityEngine的数组
// 朴素的文本填充
// 引用的命名空间
foreach (var u in us)
{
partialBuilder.AppendLine($"using {u};");
extensionBuilder.AppendLine($"using {u};");
}
// 部分类的命名空间、名称、目标类型
partialBuilder.Append($@"
{starting}
public partial class {className} : Transition
{{
private readonly {targetType} target;
");
// 拓展方法的命名空间、名称、目标类型
extensionBuilder.Append($@"
{starting}
public static class {targetType}ExtensionsForTransition
{{
");
// ctor
ctorBuilder.Append($@"
public {className}({targetType} target) : base()
{{
this.target = target;
").AppendLine();
// Init
initBuilder.Append($@"
protected override void Init()
{{
base.Init();
").AppendLine();
// Process
processBuilder.Append($@"
protected override void Process(float delta)
{{
base.Process(delta);
if (!target)
{{
Drop();
return;
}}
").AppendLine();
接下来需要对每一个符合条件的字段进行处理,以故需要遍历TransitionSyntaxReceiver.Fields中每一个FieldDeclarationSyntax:
foreach (var field in receiver.Fields) // ...
判断当前字段是否属于本轮遍历的类:
internal static string GetClass(this FieldDeclarationSyntax node)
=> (node.Parent as ClassDeclarationSyntax).Identifier.Text;
if (field.GetClass() != className) continue;
对于每一个字段,我们需要知道它的:
- 类型
- 名称
- 目标组件上与它相关的属性名称
var fieldType = field.Declaration.Type; // Color
var fieldName = field.Declaration.Variables.First().Identifier.Text; // col
// Col,用于后续的fromCol与toCol
var fieldNameNew = char.ToUpper(fieldName[0]) + fieldName.Substring(1);
// color,SpriteRenderer组件上的color属性
var relative = field.GetAttribute("TransitionField").GetUnique<LiteralExpressionSyntax>().Token.ValueText;
然后就是朴素的字符串拼接环节了:
partialBuilder.Append($@"
private {fieldType} from{fieldNameNew}, to{fieldNameNew};
private partial void Init{fieldNameNew}({targetType} target);
private partial void Process{fieldNameNew}({targetType} target, {fieldType} delta);
public {className} From{fieldNameNew}({fieldType} {fieldName})
{{
this.{fieldName} = from{fieldNameNew} = {fieldName};
return this;
}}
public {className} To{fieldNameNew}({fieldType} {fieldName})
{{
to{fieldNameNew} = {fieldName};
return this;
}}
");
extensionBuilder.Append($@"
public static {className} From{fieldNameNew}(this {targetType} target, {fieldType} {fieldName}) => new {className}(target).From{fieldNameNew}({fieldName});
public static {className} To{fieldNameNew}(this {targetType} target, {fieldType} {fieldName}) => new {className}(target).From{fieldNameNew}(target.{relative}).To{fieldNameNew}({fieldName});
public static {className} Add{fieldNameNew}(this {targetType} target, {fieldType} {fieldName}) => new {className}(target).From{fieldNameNew}(target.{relative}).To{fieldNameNew}(target.{relative} + {fieldName});
");
// 这意味着,每多一个变量,这三个方法就得多加一行,以故不能顺序写入,需要单独开一个StringBuilder
ctorBuilder.AppendLine($"\t\t\tthis.{fieldName} = from{fieldNameNew} = to{fieldNameNew} = target.{relative};");
initBuilder.AppendLine($"\t\t\tInit{fieldNameNew}(target);");
processBuilder.AppendLine($"\t\t\tProcess{fieldNameNew}(target, delta * (to{fieldNameNew} - from{fieldNameNew}));");
然后是汇总和结尾:
// 方法体结尾
ctorBuilder.AppendLine("\t\t}");
initBuilder.AppendLine("\t\t}");
processBuilder.AppendLine("\t\t}");
// 将方法添加到类中
partialBuilder.Append(ctorBuilder.ToString());
partialBuilder.Append(initBuilder.ToString());
partialBuilder.Append(processBuilder.ToString());
// 有命名空间会多一层花括号
var ending = "\n\t}" + (namesp == string.Empty ? string.Empty : "\n}");
partialBuilder.Append(ending);
extensionBuilder.Append(ending);
最后通过AddSource方法添加脚本:
ctx.AddSource($"{targetType}Transition.g.cs", SourceText.From(partialBuilder.ToString(), Encoding.UTF8));
ctx.AddSource($"{targetType}ExtensionsForTransition.g.cs", SourceText.From(extensionBuilder.ToString(), Encoding.UTF8));
如何将它应用到Unity中,查阅手册即可。
检验
在哪个项目中人工编写脚本,就能在哪个项目的引用-分析器-TransitionSourceGenerator-SubstanceP.TransitionSourceGenerator中找到生成的相应脚本。 例如,我在Scripts文件夹中写了个AudioSource.cs:
// AudioSource.cs
using UnityEngine;
using SubstanceP;
[TransitionTarget(typeof(AudioSource))]
public partial class AudioSourceTransition : Transition
{
[TransitionField("volume")] private float vol;
private partial void InitVol(AudioSource target) => target.volume = vol;
private partial void ProcessVol(AudioSource target, float delta) => target.volume += delta;
}
它不在Plugins或者Editor文件夹中,所以属于Assembly-CSharp项目,故生成的脚本应该在这个位置:

完整的项目放在这里:
https://github.com/SubstanceP114/Transition
https://github.com/SubstanceP114/TransitionSourceGenerator
如果希望了解更多细节,或者你也想启动源生,那就点进去看看吧!
参考
https://github.com/BingyanStudio/BingyanDevKit/tree/main/Runtime
https://zhuanlan.zhihu.com/p/690915950
https://docs.unity3d.org.cn/6000.0/Documentation/Manual/create-source-generator.html
https://docs.unity.cn/cn/2021.3/Manual/roslyn-analyzers.html